๐๏ธGitะฏัะฐ๐๏ธ
Commit f4506450168d047b225b277620bf0f1d5dcd13b5
Parents : b23acf6
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T02:50:23-10:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T12:50:23Z
feat(messaging): add optional full message timestamps (#6639)
Changes
23 files changed, 261 insertions(+), 6 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index a909fe5141..5bcab3b31b 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1571,6 +1571,8 @@ should_update
should_update_firmware
### SHOW ###
show_all_key_title
+show_full_message_timestamps
+show_full_message_timestamps_summary
show_iaq_legend
show_layer
show_original
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
index 5b48c14ff0..26dacf2a74 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
@@ -131,6 +131,15 @@ class UiPrefsImpl(private val dataStore: UiDataStore, dispatchers: CoroutineDisp
scope.launch { dataStore.edit { it[KEY_SHOW_QUICK_CHAT_PREF] = show } }
}
+ override val showFullMessageTimestamps: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[KEY_SHOW_FULL_MESSAGE_TIMESTAMPS] ?: false }
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setShowFullMessageTimestamps(show: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_SHOW_FULL_MESSAGE_TIMESTAMPS] = show } }
+ }
+
override val eventThemeEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_EVENT_THEME_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
@@ -295,6 +304,7 @@ class UiPrefsImpl(private val dataStore: UiDataStore, dispatchers: CoroutineDisp
companion object {
val KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF = booleanPreferencesKey("has_shown_not_paired_warning")
val KEY_SHOW_QUICK_CHAT_PREF = booleanPreferencesKey("show-quick-chat")
+ val KEY_SHOW_FULL_MESSAGE_TIMESTAMPS = booleanPreferencesKey("show-full-message-timestamps")
val KEY_EVENT_THEME_ENABLED = booleanPreferencesKey("event-theme-enabled")
val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed")
diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt
index fd6bca423a..084cac9a40 100644
--- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt
+++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt
@@ -21,6 +21,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -34,7 +35,9 @@ import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNull
+import kotlin.test.assertTrue
import kotlin.uuid.Uuid
class UiPrefsImplTest {
@@ -126,6 +129,19 @@ class UiPrefsImplTest {
assertEquals(DeviceType.USB, prefs.selectedConnectionTransport.value)
}
+ @Test
+ fun `full message timestamps default to false`() =
+ testScope.runTest { assertFalse(prefs.showFullMessageTimestamps.value) }
+
+ @Test
+ fun `full message timestamps persist when enabled`() = testScope.runTest {
+ prefs.setShowFullMessageTimestamps(true)
+
+ val stored = dataStore.data.first { it[UiPrefsImpl.KEY_SHOW_FULL_MESSAGE_TIMESTAMPS] == true }
+ assertTrue(prefs.showFullMessageTimestamps.value)
+ assertEquals(true, stored[UiPrefsImpl.KEY_SHOW_FULL_MESSAGE_TIMESTAMPS])
+ }
+
@Test
fun `firmware update notification keys persist without duplicates`() = testScope.runTest {
prefs.recordFirmwareUpdateNotificationKey("firmware-update-notified:node:target:2.8.0")
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index 068047d07d..743bf8781c 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -132,6 +132,11 @@ interface UiPrefs {
fun setShowQuickChat(show: Boolean)
+ /** Whether conversation message headers and actions always show both the date and time. */
+ val showFullMessageTimestamps: StateFlow<Boolean>
+
+ fun setShowFullMessageTimestamps(show: Boolean)
+
/**
* Whether to apply an event edition's ambient theme (accent wash + custom typeface) app-wide (opt-out; default on).
*/
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 06cb046cbc..aad22422a2 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1616,6 +1616,8 @@
<string name="should_update_firmware">Firmware Update Recommended.</string>
<!-- SHOW -->
<string name="show_all_key_title">Encryption Key Meanings</string>
+ <string name="show_full_message_timestamps">Show full message timestamps</string>
+ <string name="show_full_message_timestamps_summary">Always show the date and time in conversation headers and message actions</string>
<string name="show_iaq_legend">Show air quality legend</string>
<string name="show_layer">Show Layer</string>
<string name="show_original">Show original</string>
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index e58993d130..3c325fbc3e 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -152,6 +152,12 @@ class FakeUiPrefs : UiPrefs {
showQuickChat.value = show
}
+ override val showFullMessageTimestamps = MutableStateFlow(false)
+
+ override fun setShowFullMessageTimestamps(show: Boolean) {
+ showFullMessageTimestamps.value = show
+ }
+
override val eventThemeEnabled = MutableStateFlow(true)
override fun setEventThemeEnabled(enabled: Boolean) {
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
index 1fcf32d27b..48afb5ff74 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -174,6 +174,7 @@ fun MessageScreen(
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
val messageInputState = rememberTextFieldState(message.ifEmpty { viewModel.draftMessage.value })
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
+ val showFullMessageTimestamps by viewModel.showFullMessageTimestamps.collectAsStateWithLifecycle()
val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle()
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
@@ -473,6 +474,7 @@ fun MessageScreen(
filteringDisabled = filteringDisabled,
searchQuery = if (isSearchActive) searchQuery else "",
translationAvailable = translationAvailable,
+ showFullMessageTimestamps = showFullMessageTimestamps,
),
handlers =
MessageListHandlers(
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
index 2b48fbd72b..810528063d 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
@@ -102,6 +102,7 @@ internal data class MessageListPagedState(
val filteringDisabled: Boolean = false,
val searchQuery: String = "",
val translationAvailable: Boolean = false,
+ val showFullMessageTimestamps: Boolean = false,
)
private fun MutableState<Set<Long>>.toggle(uuid: Long) {
@@ -362,6 +363,7 @@ private fun RenderPagedChatMessageRow(
onReply = { handlers.onReply(message) },
emojis = message.emojis,
showUserName = showUserName,
+ showFullMessageTimestamp = state.showFullMessageTimestamps,
sendReaction = { emoji ->
val hasReacted =
message.emojis.any { reaction ->
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index 3356ce6efd..958c38cb35 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -146,6 +146,8 @@ class MessageViewModel(
val showQuickChat = uiPrefs.showQuickChat
+ val showFullMessageTimestamps = uiPrefs.showFullMessageTimestamps
+
private val _showFiltered = MutableStateFlow(false)
val showFiltered: StateFlow<Boolean> = _showFiltered.asStateFlow()
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
index 14481dfb30..e365bc00ae 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
@@ -111,7 +111,7 @@ fun MessageActionsContent(
)
}
- // Bubbles only show a short header time per run, so the sheet is where the full date-time lives.
+ // The caller supplies the same compact or full timestamp shown in the conversation header.
if (timestamp != null) {
ListItem(
headlineContent = {
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
index fc384b8770..c1ccd85313 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
@@ -60,6 +60,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
@@ -93,6 +94,17 @@ import org.meshtastic.core.ui.util.createClipEntry
internal const val MESSAGE_STATUS_LABEL_TEST_TAG = "message_status_label"
+/** Selects the persisted compact label or formats the raw display instant with platform date/time conventions. */
+internal fun formatMessageTimestamp(
+ message: Message,
+ showFullMessageTimestamp: Boolean,
+ fullDateTimeFormatter: (Long) -> String = DateFormatter::formatDateTime,
+): String = if (showFullMessageTimestamp) {
+ fullDateTimeFormatter(message.displayTime)
+} else {
+ message.time
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@@ -107,6 +119,7 @@ fun MessageItem(
sendReaction: (String) -> Unit = {},
onShowReactions: () -> Unit = {},
showUserName: Boolean = true,
+ showFullMessageTimestamp: Boolean = false,
emojis: List<Reaction> = emptyList(),
quickEmojis: List<String> = listOf("๐", "๐", "๐", "๐ฅ", "โค๏ธ", "๐ฎ"),
onClick: () -> Unit = {},
@@ -144,6 +157,7 @@ fun MessageItem(
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isLocal = node.num == ourNode.num
+ val timestamp = formatMessageTimestamp(message, showFullMessageTimestamp)
val statusString = message.getStatusStringRes(isDirectMessage)
val isDirectImplicitAck = message.status == MessageStatus.DELIVERED && isDirectMessage
val isRetryableFailure = message.status == MessageStatus.ERROR && message.isStatusRetryable(isDirectMessage)
@@ -188,7 +202,7 @@ fun MessageItem(
},
// Mesh time (rx_time), matching the bubble header. receivedTime is when this phone
// pulled the packet off the node, which is misleading after an offline backlog sync.
- timestamp = message.time,
+ timestamp = timestamp,
xeddsaSigned = message.xeddsaSigned,
onStatus = onStatusClick,
translationRowState = translationRowStateFor(message, translationAvailable),
@@ -256,7 +270,7 @@ fun MessageItem(
if (showUserName) {
if (message.fromLocal) {
Text(
- text = message.time,
+ text = timestamp,
modifier = Modifier.align(Alignment.End).padding(end = 12.dp, bottom = 2.dp),
style = metadataStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -276,7 +290,7 @@ fun MessageItem(
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- Text(text = message.time, style = metadataStyle, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(text = timestamp, style = metadataStyle, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
index 351d9d3f13..903528ae32 100644
--- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
@@ -80,6 +80,7 @@ class MessageViewModelTest {
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val showQuickChatFlow = MutableStateFlow(false)
+ private val showFullMessageTimestampsFlow = MutableStateFlow(false)
private val customEmojiFrequencyFlow = MutableStateFlow<String?>(null)
private val contactSettingsFlow = MutableStateFlow<Map<String, ContactSettings>>(emptyMap())
@@ -91,6 +92,7 @@ class MessageViewModelTest {
connectionStateFlow.value = ConnectionState.Disconnected
showQuickChatFlow.value = false
+ showFullMessageTimestampsFlow.value = false
customEmojiFrequencyFlow.value = null
contactSettingsFlow.value = emptyMap()
@@ -106,6 +108,7 @@ class MessageViewModelTest {
every { homoglyphPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
every { uiPrefs.showQuickChat } returns showQuickChatFlow
every { uiPrefs.setShowQuickChat(any()) } returns Unit
+ every { uiPrefs.showFullMessageTimestamps } returns showFullMessageTimestampsFlow
every { packetRepository.getContactSettings() } returns contactSettingsFlow
every { packetRepository.getFirstUnreadMessageUuid(any<String>()) } returns MutableStateFlow(null)
@@ -214,6 +217,17 @@ class MessageViewModelTest {
}
}
+ @Test
+ fun testShowFullMessageTimestampsReflectsUiPreference() = runTest {
+ viewModel.showFullMessageTimestamps.test {
+ assertEquals(false, awaitItem())
+
+ showFullMessageTimestampsFlow.value = true
+ assertEquals(true, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
@Test
fun testFrequentEmojis() = runTest {
customEmojiFrequencyFlow.value = "๐=10,๐=5,๐=20"
diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTranslationTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTranslationTest.kt
index 1b39abbdc2..968608f647 100644
--- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTranslationTest.kt
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTranslationTest.kt
@@ -170,6 +170,7 @@ class MessageViewModelTranslationTest {
every { customEmojiPrefs.customEmojiFrequency } returns MutableStateFlow<String?>(null)
every { homoglyphPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
every { uiPrefs.showQuickChat } returns MutableStateFlow(false)
+ every { uiPrefs.showFullMessageTimestamps } returns MutableStateFlow(false)
every { packetRepository.getContactSettings() } returns
MutableStateFlow<Map<String, ContactSettings>>(emptyMap())
every { quickChatActionRepository.getAllActions() } returns MutableStateFlow(emptyList())
diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
index 11c9bce964..ed19465ddf 100644
--- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
@@ -27,6 +27,7 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runComposeUiTest
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
@@ -362,6 +363,29 @@ class MessageItemTest {
onNodeWithContentDescription("Message delivery status", useUnmergedTree = true).assertDoesNotExist()
}
+ @Test
+ fun fullMessageTimestampIsDisplayedInMessageHeader() = runComposeUiTest {
+ val testNode = NodePreviewParameterProvider().mickeyMouse
+ val meshTime = 1_700_000_000_000L
+ val message =
+ localMessage(node = testNode, status = MessageStatus.RECEIVED).copy(time = "compact", meshTime = meshTime)
+ val expectedTimestamp = DateFormatter.formatDateTime(meshTime)
+
+ setContent {
+ MessageItem(
+ message = message,
+ node = testNode,
+ selected = false,
+ onStatusClick = {},
+ ourNode = testNode,
+ showFullMessageTimestamp = true,
+ )
+ }
+
+ onNodeWithText(expectedTimestamp, useUnmergedTree = true).assertIsDisplayed()
+ onNodeWithText("compact", useUnmergedTree = true).assertDoesNotExist()
+ }
+
private fun localMessage(node: Node, status: MessageStatus, routingError: Int = 0) = Message(
text = "Local message",
time = "10:00",
diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageTimestampTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageTimestampTest.kt
new file mode 100644
index 0000000000..cbd1d69357
--- /dev/null
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageTimestampTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.messaging.component
+
+import org.meshtastic.core.model.Message
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+
+class MessageTimestampTest {
+ @Test
+ fun compactMessageTimestampDoesNotInvokeFullFormatter() {
+ var formatterCalled = false
+
+ val timestamp =
+ formatMessageTimestamp(message(time = "compact"), showFullMessageTimestamp = false) {
+ formatterCalled = true
+ "full"
+ }
+
+ assertEquals("compact", timestamp)
+ assertFalse(formatterCalled)
+ }
+
+ @Test
+ fun fullMessageTimestampUsesRawMeshTime() {
+ val meshTime = 1_700_000_000_000L
+ val message = message(time = "compact", receivedTime = meshTime + 1_000L, meshTime = meshTime)
+
+ val timestamp = formatMessageTimestamp(message, showFullMessageTimestamp = true) { "formatted:$it" }
+
+ assertEquals("formatted:$meshTime", timestamp)
+ }
+
+ @Test
+ fun fullMessageTimestampFallsBackToReceivedTime() {
+ val receivedTime = 1_700_000_000_000L
+ val message = message(time = "compact", receivedTime = receivedTime, meshTime = 0L)
+
+ val timestamp = formatMessageTimestamp(message, showFullMessageTimestamp = true) { "formatted:$it" }
+
+ assertEquals("formatted:$receivedTime", timestamp)
+ }
+
+ private fun message(time: String, receivedTime: Long = 1L, meshTime: Long = 0L): Message {
+ val node = NodePreviewParameterProvider().mickeyMouse
+ return Message(
+ text = "Message",
+ time = time,
+ fromLocal = true,
+ status = MessageStatus.RECEIVED,
+ snr = null,
+ rssi = null,
+ hopsAway = 0,
+ uuid = 1L,
+ receivedTime = receivedTime,
+ meshTime = meshTime,
+ node = node,
+ read = false,
+ routingError = 0,
+ packetId = 1,
+ emojis = emptyList(),
+ replyId = null,
+ )
+ }
+}
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index f1004d77d5..c3c94e312b 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -103,6 +103,7 @@ fun SettingsScreen(
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
+ val showFullMessageTimestamps by settingsViewModel.showFullMessageTimestamps.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
@@ -258,6 +259,8 @@ fun SettingsScreen(
stopProvideLocation = { settingsViewModel.stopProvidingLocation() },
)
AppearanceSettingsContent(
+ showFullMessageTimestamps = showFullMessageTimestamps,
+ onShowFullMessageTimestampsChange = settingsViewModel::setShowFullMessageTimestamps,
onShowLanguagePicker = { showLanguagePickerDialog = true },
onShowThemePicker = { showThemePickerDialog = true },
)
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
index 543e4dc9c9..70d6aa82f9 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
@@ -40,7 +40,12 @@ import org.meshtastic.core.ui.theme.AppTheme
/** Section for app appearance settings like language and theme. */
@Composable
-internal fun ColumnScope.AppearanceSettingsContent(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) {
+internal fun ColumnScope.AppearanceSettingsContent(
+ showFullMessageTimestamps: Boolean,
+ onShowFullMessageTimestampsChange: (Boolean) -> Unit,
+ onShowLanguagePicker: () -> Unit,
+ onShowThemePicker: () -> Unit,
+) {
val context = LocalContext.current
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
@@ -70,6 +75,11 @@ internal fun ColumnScope.AppearanceSettingsContent(onShowLanguagePicker: () -> U
ListItem(text = stringResource(Res.string.theme), leadingIcon = MeshtasticIcons.FormatPaint, trailingIcon = null) {
onShowThemePicker()
}
+
+ FullMessageTimestampsSetting(
+ checked = showFullMessageTimestamps,
+ onCheckedChange = onShowFullMessageTimestampsChange,
+ )
}
@Preview(showBackground = true)
@@ -77,7 +87,12 @@ internal fun ColumnScope.AppearanceSettingsContent(onShowLanguagePicker: () -> U
fun AppearanceSectionPreview() {
AppTheme {
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
- AppearanceSettingsContent(onShowLanguagePicker = {}, onShowThemePicker = {})
+ AppearanceSettingsContent(
+ showFullMessageTimestamps = false,
+ onShowFullMessageTimestampsChange = {},
+ onShowLanguagePicker = {},
+ onShowThemePicker = {},
+ )
}
}
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
index 10583df3e7..c14310c61d 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
@@ -153,6 +153,12 @@ class SettingsViewModel(
uiPrefs.setTheme(theme)
}
+ val showFullMessageTimestamps = uiPrefs.showFullMessageTimestamps
+
+ fun setShowFullMessageTimestamps(show: Boolean) {
+ uiPrefs.setShowFullMessageTimestamps(show)
+ }
+
/** Set the application locale. Empty string means system default. */
fun setLocale(languageTag: String) {
uiPrefs.setLocale(languageTag)
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/FullMessageTimestampsSetting.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/FullMessageTimestampsSetting.kt
new file mode 100644
index 0000000000..cee25d1050
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/FullMessageTimestampsSetting.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.component
+
+import androidx.compose.runtime.Composable
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.show_full_message_timestamps
+import org.meshtastic.core.resources.show_full_message_timestamps_summary
+import org.meshtastic.core.ui.component.SwitchPreference
+
+@Composable
+internal fun FullMessageTimestampsSetting(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ SwitchPreference(
+ title = stringResource(Res.string.show_full_message_timestamps),
+ summary = stringResource(Res.string.show_full_message_timestamps_summary),
+ checked = checked,
+ enabled = true,
+ onCheckedChange = onCheckedChange,
+ )
+}
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
index 4103e19d2a..017e34d1eb 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
@@ -230,6 +230,13 @@ class SettingsViewModelTest {
appPreferences.ui.theme.value shouldBe 2
}
+ @Test
+ fun `setShowFullMessageTimestamps updates prefs`() = runTest {
+ viewModel.setShowFullMessageTimestamps(true)
+
+ appPreferences.ui.showFullMessageTimestamps.value shouldBe true
+ }
+
@Test
fun `setLocale updates prefs`() = runTest {
viewModel.setLocale("fr")
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
index 647c449719..4466001ded 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
@@ -77,6 +77,7 @@ import org.meshtastic.core.ui.icon.PermScanWifi
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.component.ExpressiveSection
+import org.meshtastic.feature.settings.component.FullMessageTimestampsSetting
import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.meshtastic.feature.settings.component.NotificationSection
import org.meshtastic.feature.settings.component.ThemePickerDialog
@@ -107,6 +108,7 @@ fun DesktopSettingsScreen(
val hiddenFeaturesUnlocked by settingsViewModel.hiddenFeaturesUnlocked.collectAsStateWithLifecycle()
val cacheLimit by settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle()
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
+ val showFullMessageTimestamps by settingsViewModel.showFullMessageTimestamps.collectAsStateWithLifecycle()
var showThemePickerDialog by remember { mutableStateOf(false) }
var showLanguagePickerDialog by remember { mutableStateOf(false) }
@@ -189,6 +191,11 @@ fun DesktopSettingsScreen(
showLanguagePickerDialog = true
}
+ FullMessageTimestampsSetting(
+ checked = showFullMessageTimestamps,
+ onCheckedChange = settingsViewModel::setShowFullMessageTimestamps,
+ )
+
HomoglyphSetting(
homoglyphEncodingEnabled = homoglyphEnabled,
onToggle = { radioConfigViewModel.toggleHomoglyphCharactersEncodingEnabled() },
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotAppearanceSection_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotAppearanceSection_Dark_d19fbf1f_0.png
index a8d0eab527..7058ccc8de 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotAppearanceSection_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotAppearanceSection_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotAppearanceSection_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotAppearanceSection_Light_b29dc7a7_0.png
index ff3b997181..b951d55703 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotAppearanceSection_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotAppearanceSection_Light_b29dc7a7_0.png differ
Served by rngit 1.5.0 - Generated in 0.34s